CreateVectorTableSheet.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { literal, safeSql } from '@supabase/pg-meta'
  3. import { PermissionAction } from '@supabase/shared-types/out/constants'
  4. import { Plus, Trash2 } from 'lucide-react'
  5. import { parseAsBoolean, useQueryState } from 'nuqs'
  6. import { useEffect } from 'react'
  7. import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
  8. import { toast } from 'sonner'
  9. import {
  10. Button,
  11. Form,
  12. FormControl,
  13. FormField,
  14. Input,
  15. RadioGroupStacked,
  16. RadioGroupStackedItem,
  17. Separator,
  18. Sheet,
  19. SheetContent,
  20. SheetFooter,
  21. SheetHeader,
  22. SheetSection,
  23. SheetTitle,
  24. SheetTrigger,
  25. } from 'ui'
  26. import { Admonition } from 'ui-patterns'
  27. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  28. import z from 'zod'
  29. import { inverseValidBucketNameRegex } from '../CreateBucketModal.utils'
  30. import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance'
  31. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  32. import { DocsButton } from '@/components/ui/DocsButton'
  33. import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
  34. import { useVectorBucketIndexCreateMutation } from '@/data/storage/vector-bucket-index-create-mutation'
  35. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  36. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  37. import { DOCS_URL } from '@/lib/constants'
  38. const isStagingLocal = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
  39. const BUCKET_INDEX_NAME_REGEX = /^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$/
  40. const DISTANCE_METRICS = [
  41. {
  42. value: 'cosine',
  43. label: 'Cosine',
  44. description: 'Measures similarity between two vectors, based on directions, not magnitude.',
  45. },
  46. {
  47. value: 'euclidean',
  48. label: 'Euclidean',
  49. description:
  50. 'Measures straight-line distance between two vectors, using both directions and magnitudes.',
  51. },
  52. ] as const
  53. const FormSchema = z.object({
  54. name: z
  55. .string()
  56. .trim()
  57. .min(3, 'Name must be at least 3 characters')
  58. .max(63, 'Name must be below 63 characters')
  59. .refine(
  60. (value) => value !== 'public',
  61. '"public" is a reserved name. Please choose another name'
  62. )
  63. .superRefine((name, ctx) => {
  64. if (!BUCKET_INDEX_NAME_REGEX.test(name)) {
  65. const [match] = name.match(inverseValidBucketNameRegex) ?? []
  66. ctx.addIssue({
  67. path: [],
  68. code: z.ZodIssueCode.custom,
  69. message: !!match
  70. ? `Bucket name cannot contain the "${match}" character`
  71. : 'Bucket name contains an invalid special character',
  72. })
  73. }
  74. }),
  75. dimension: z
  76. .number()
  77. .int('Dimension must be an integer')
  78. .min(1, 'Dimension must be at least 1')
  79. .max(4096, 'Dimension must be at most 4096'),
  80. distanceMetric: z.enum(['cosine', 'euclidean'], {
  81. required_error: 'Please select a distance metric',
  82. }),
  83. metadataKeys: z
  84. .array(
  85. z.object({
  86. value: z.string().min(1, 'The metadata key needs to be at least 1 character long'),
  87. })
  88. )
  89. .default([]),
  90. })
  91. const formId = 'create-vector-table-form'
  92. export type CreateVectorTableForm = z.infer<typeof FormSchema>
  93. interface CreateVectorTableSheetProps {
  94. bucketName?: string
  95. }
  96. export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => {
  97. const { data: project } = useSelectedProjectQuery()
  98. const [visible, setVisible] = useQueryState(
  99. 'newTable',
  100. parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
  101. )
  102. const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
  103. const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId: bucketName })
  104. const schema = (wrapperInstance?.server_options ?? [])
  105. .find((x) => x.startsWith('briven_target_schema'))
  106. ?.split('briven_target_schema=')[1]
  107. // [Joshen] Can remove this once this restriction is removed
  108. const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1'
  109. const defaultValues = {
  110. name: '',
  111. dimension: undefined,
  112. distanceMetric: 'cosine' as 'cosine' | 'euclidean',
  113. metadataKeys: [],
  114. }
  115. const form = useForm<CreateVectorTableForm>({
  116. resolver: zodResolver(FormSchema as any),
  117. defaultValues,
  118. values: defaultValues as any,
  119. })
  120. const { fields, append, remove } = useFieldArray({
  121. control: form.control,
  122. name: 'metadataKeys',
  123. })
  124. const { mutateAsync: createVectorBucketTable, isPending: isCreatingVectorBucketTable } =
  125. useVectorBucketIndexCreateMutation()
  126. const { mutateAsync: importForeignSchema, isPending: isImportingForeignSchema } =
  127. useFDWImportForeignSchemaMutation({
  128. onError: () => {},
  129. })
  130. const isCreating = isCreatingVectorBucketTable || isImportingForeignSchema
  131. const onSubmit: SubmitHandler<CreateVectorTableForm> = async (values) => {
  132. if (!project?.ref) return console.error('Project ref is required')
  133. if (!bucketName) return console.error('Bucket name is required')
  134. try {
  135. await createVectorBucketTable({
  136. projectRef: project.ref,
  137. bucketName: bucketName,
  138. indexName: values.name,
  139. dataType: 'float32',
  140. dimension: values.dimension!,
  141. distanceMetric: values.distanceMetric,
  142. metadataKeys: values.metadataKeys.map((key) => key.value),
  143. })
  144. } catch (error: any) {
  145. toast.error(`Failed to create vector table: ${error.message}`)
  146. return
  147. }
  148. try {
  149. if (wrapperInstance && !!schema) {
  150. await importForeignSchema({
  151. projectRef: project.ref,
  152. connectionString: project?.connectionString,
  153. serverName: wrapperInstance.server_name,
  154. sourceSchema: schema,
  155. targetSchema: schema,
  156. schemaOptions: [safeSql`bucket_name ${literal(bucketName)}`],
  157. })
  158. }
  159. } catch (error: any) {
  160. toast.warning(`Failed to connect vector table to the database: ${error.message}`)
  161. }
  162. toast.success(`Successfully created vector table “${values.name}”`)
  163. form.reset()
  164. setVisible(false)
  165. }
  166. useEffect(() => {
  167. if (!visible) {
  168. form.reset()
  169. }
  170. }, [visible])
  171. return (
  172. <Sheet open={visible} onOpenChange={setVisible}>
  173. <SheetTrigger asChild>
  174. <ButtonTooltip
  175. block
  176. size="tiny"
  177. type="primary"
  178. className="w-fit"
  179. icon={<Plus size={14} />}
  180. disabled={!canCreateBuckets}
  181. onClick={() => setVisible(true)}
  182. tooltip={{
  183. content: {
  184. side: 'bottom',
  185. text: !canCreateBuckets
  186. ? 'You need additional permissions to create buckets'
  187. : undefined,
  188. },
  189. }}
  190. >
  191. Create table
  192. </ButtonTooltip>
  193. </SheetTrigger>
  194. <SheetContent size="default" className="flex flex-col gap-0 p-0">
  195. <SheetHeader>
  196. <SheetTitle>Create vector table</SheetTitle>
  197. </SheetHeader>
  198. {showIndexCreationNotice && (
  199. <Admonition
  200. type="warning"
  201. className="border-x-0 border-t-0 rounded-none"
  202. title="Vector table creation is currently only supported for projects in us-east-1"
  203. description={`This is only applicable to projects on local/staging (Project is currently in ${project.region})`}
  204. />
  205. )}
  206. <Form {...form}>
  207. <form
  208. id={formId}
  209. onSubmit={form.handleSubmit(onSubmit)}
  210. className="overflow-auto grow px-0"
  211. >
  212. <SheetSection className="flex flex-col gap-y-4">
  213. <FormField
  214. key="name"
  215. name="name"
  216. control={form.control}
  217. render={({ field }) => (
  218. <FormItemLayout
  219. name="name"
  220. label="Name"
  221. description="Must be between 3–63 characters. Valid characters are a-z, 0-9, hyphens, and periods."
  222. layout="horizontal"
  223. >
  224. <FormControl>
  225. <Input
  226. id="name"
  227. data-1p-ignore
  228. data-lpignore="true"
  229. data-form-type="other"
  230. data-bwignore
  231. {...field}
  232. placeholder="Enter a table name"
  233. />
  234. </FormControl>
  235. </FormItemLayout>
  236. )}
  237. />
  238. </SheetSection>
  239. <Separator />
  240. <SheetSection className="flex flex-col gap-y-4">
  241. <FormField
  242. key="dimension"
  243. name="dimension"
  244. control={form.control}
  245. render={({ field }) => (
  246. <FormItemLayout
  247. name="dimension"
  248. label="Dimension"
  249. description="Must be an integer between 1–4096."
  250. layout="horizontal"
  251. >
  252. <FormControl>
  253. <Input
  254. id="dimension"
  255. type="number"
  256. placeholder="Enter a numeric value"
  257. {...field}
  258. onChange={(e) => {
  259. const value = e.target.value
  260. field.onChange(value === '' ? undefined : Number(value))
  261. }}
  262. value={field.value ?? ''}
  263. />
  264. </FormControl>
  265. </FormItemLayout>
  266. )}
  267. />
  268. <FormField
  269. key="distanceMetric"
  270. name="distanceMetric"
  271. control={form.control}
  272. render={({ field }) => (
  273. <FormItemLayout
  274. name="distanceMetric"
  275. label="Distance metric"
  276. layout="horizontal"
  277. className="gap-1"
  278. >
  279. <FormControl>
  280. <RadioGroupStacked
  281. id="distance_metric"
  282. name="distance_metric"
  283. value={field.value}
  284. disabled={field.disabled}
  285. onValueChange={field.onChange}
  286. >
  287. {DISTANCE_METRICS.map((metric) => (
  288. <RadioGroupStackedItem
  289. key={metric.value}
  290. id={metric.value}
  291. value={metric.value}
  292. label={metric.label}
  293. description={metric.description}
  294. showIndicator={true}
  295. ></RadioGroupStackedItem>
  296. ))}
  297. </RadioGroupStacked>
  298. </FormControl>
  299. </FormItemLayout>
  300. )}
  301. />
  302. </SheetSection>
  303. <Separator />
  304. <SheetSection className="space-y-4">
  305. <div className="flex items-center justify-between">
  306. <label className="text-sm text-foreground">Metadata keys</label>
  307. <DocsButton
  308. href={`${DOCS_URL}/guides/storage/vector/storing-vectors#metadata-best-practices`}
  309. />
  310. </div>
  311. <div className="space-y-2">
  312. {fields.map((field, index) => (
  313. <div key={field.id} className="flex items-start gap-2">
  314. <div className="flex-1">
  315. <FormField
  316. control={form.control}
  317. name={`metadataKeys.${index}.value`}
  318. render={({ field }) => (
  319. <FormItemLayout
  320. name={`metadataKeys.${index}.value`}
  321. description={
  322. index === fields.length - 1
  323. ? 'Must be between 1–63 characters and unique within this table.'
  324. : undefined
  325. }
  326. layout="vertical"
  327. >
  328. <FormControl>
  329. <Input
  330. {...field}
  331. value={field.value}
  332. size="small"
  333. className="w-full"
  334. placeholder="Enter a metadata key name"
  335. data-1p-ignore
  336. data-lpignore="true"
  337. data-form-type="other"
  338. data-bwignore
  339. />
  340. </FormControl>
  341. </FormItemLayout>
  342. )}
  343. />
  344. </div>
  345. <Button
  346. type="text"
  347. className="w-[34px] h-[34px]" // Match the height of the input
  348. size="tiny"
  349. icon={<Trash2 size={12} />}
  350. onClick={() => remove(index)}
  351. />
  352. </div>
  353. ))}
  354. </div>
  355. <div className="flex items-center justify-center rounded-sm border border-strong border-dashed py-3">
  356. <Button type="default" size="tiny" onClick={() => append({ value: '' })}>
  357. Add metadata key
  358. </Button>
  359. </div>
  360. </SheetSection>
  361. </form>
  362. </Form>
  363. <SheetFooter>
  364. <Button type="default" disabled={isCreating} onClick={() => setVisible(false)}>
  365. Cancel
  366. </Button>
  367. <Button
  368. form={formId}
  369. htmlType="submit"
  370. loading={isCreating}
  371. disabled={isCreating || !bucketName}
  372. >
  373. Create
  374. </Button>
  375. </SheetFooter>
  376. </SheetContent>
  377. </Sheet>
  378. )
  379. }